Postgres: изоляция транзакций, блокировки, deadlock
Продолжение статьи «Защита тестового Avito на реальном проекте: Avito.Кухня» — там уже разобран атомарный UPDATE items SET stock = stock - $qty WHERE id = $id AND stock >= $qty и сортировка позиций заказа по item_id перед списанием. Здесь — почему это работает на уровне СУБД, и что ещё стоит знать про блокировки Postgres сверх этого конкретного случая.
Уровни изоляции: что каждый на самом деле предотвращает
Postgres поддерживает четыре уровня изоляции по стандарту SQL, но реализует только три различимых поведения (READ UNCOMMITTED в Postgres ведёт себя как READ COMMITTED — грязного чтения тут нет никогда, даже на самом слабом уровне):
- READ COMMITTED (дефолт) — каждый оператор внутри транзакции видит снимок данных, зафиксированный на момент НАЧАЛА этого оператора. Предотвращает dirty read (чтение незакоммиченных чужих данных). НЕ предотвращает non-repeatable read (два
SELECTодной и той же строки в одной транзакции могут вернуть разные значения, если между ними кто-то успел закоммитить) и phantom read (повторныйSELECTпо условию может найти новые строки). - REPEATABLE READ — снимок фиксируется на момент начала всей транзакции, а не каждого оператора. Дополнительно предотвращает non-repeatable read и phantom read. Не предотвращает write skew — ситуацию, где две транзакции читают пересекающиеся данные, каждая пишет в свою непересекающуюся часть, но вместе итоговое состояние нарушает инвариант, который был бы соблюдён при последовательном выполнении.
- SERIALIZABLE — Postgres гарантирует результат, эквивалентный какому-то последовательному порядку выполнения транзакций, вплоть до отмены (
serialization failure, код ошибки40001) одной из конфликтующих транзакций, если гарантию иначе не удержать. Это единственный уровень, устраняющий write skew.
Главный факт не про список аномалий, а про конкретный кейс списания остатка: READ COMMITTED не спасает от read-then-write гонки, даже если в коде нет ни одной классической аномалии чтения. Проблема не в том, что транзакция видит "неправильные" данные — проблема в том, что между SELECT stock и последующим UPDATE stock = <вычисленное значение> эти два действия физически не атомарны, и вторая параллельная транзакция может успеть сделать то же самое между ними.
READ COMMITTED честно показывает каждой транзакции актуальный на момент SELECT остаток — просто ничего не мешает второй транзакции прочитать тот же остаток чуть позже, но всё ещё до того, как первая закоммитит своё изменение.
Проверено вживую (одноразовый контейнер postgres:16, таблица items(id, stock), stock=10, две параллельные транзакции, каждая читает остаток, "думает" 2 секунды (pg_sleep, имитация работы приложения между чтением и записью), затем пишет прочитанное_значение - 5):
BEGIN; SELECT stock ...; pg_sleep(2); UPDATE items SET stock = :прочитанное - 5; COMMIT;
Обе транзакции читают stock=10 до того, как любая из них закоммитила — обе вычисляют 10-5=5, обе успешно обновляют строку (UPDATE 1 у каждой, без единой ошибки или конфликта). Итоговый остаток: 5, хотя корректный результат при двух списаниях по 5 из 10 — 0. Один запрос молча "потерялся" — классический lost update, и READ COMMITTED тут ничего не сигнализирует, обе транзакции коммитятся без единой ошибки.
BEGIN;
SELECT stock FROM items WHERE id = $1;
-- в Go: newStock := stock - qty
UPDATE items SET stock = $2 WHERE id = $1;
COMMIT;BEGIN;
UPDATE items
SET stock = stock - $2
WHERE id = $1 AND stock >= $2;
COMMIT;Тот же эксперимент с атомарным UPDATE items SET stock = stock - 5 WHERE id=1 AND stock >= 5 вместо двухшагового чтения-записи: обе параллельные транзакции коммитятся, итоговый остаток — 0, ровно как ожидается. Разница не в уровне изоляции (обе версии выполнялись на дефолтном READ COMMITTED) — разница в том, что вычисление нового значения происходит внутри одного SQL-оператора, на актуальных данных строки в момент её физической блокировки движком, а не на значении, вычисленном заранее в приложении.
Проверено дополнительно: SELECT ... FOR UPDATE перед чтением тоже чинит гонку (вторая транзакция блокируется на SELECT ... FOR UPDATE, пока первая не закоммитит, и видит уже обновлённое значение) — тот же эксперимент с FOR UPDATE вместо простого SELECT тоже даёт корректный итог 0. Разница с атомарным UPDATE ... WHERE: FOR UPDATE держит блокировку строки на всё время транзакции (включая pg_sleep/бизнес-логику между чтением и записью), атомарный UPDATE — только на время самого оператора, что и обсуждается в статье про Avito.Кухня как причина выбрать именно его для высокого RPS.
FOR UPDATE SKIP LOCKED — очередь задач без конкуренции воркеров
SELECT ... FOR UPDATE SKIP LOCKED — блокирует найденные строки как обычный FOR UPDATE, но пропускает строки, уже заблокированные другой транзакцией, вместо того чтобы ждать их освобождения:
SELECT id, payload FROM outbox_events
WHERE published_at IS NULL
ORDER BY id
LIMIT 10
FOR UPDATE SKIP LOCKED;
Это стандартный паттерн "таблица Postgres как очередь задач": несколько воркеров одновременно выполняют такой запрос, и каждый гарантированно получает свой набор строк — ни один не ждёт, ни один не заберёт то, что уже в обработке у другого. Без SKIP LOCKED (или тем более без FOR UPDATE вообще) несколько воркеров либо сериализовались бы в очередь ожидания блокировки, либо — что хуже — прочитали и попытались опубликовать одни и те же неопубликованные строки одновременно.
Прямое следствие для проекта Avito.Кухня из статьи выше: relay-воркер outbox читает неопубликованные строки обычным SELECT без FOR UPDATE SKIP LOCKED — при одном инстансе relay это не проблема, но при горизонтальном масштабировании (два инстанса relay одновременно) оба возьмут одни и те же строки и попытаются опубликовать их дважды.
Для system-consumer'а это не катастрофа (Kafka-consumer идемпотентен по построению, дубль сообщения — ожидаемый и безопасный случай at-least-once доставки), но конкретно это — известный, осознанно называемый на интервью пробел, а не скрытый баг: "single-instance relay безопасен как есть; для горизонтального масштабирования нужен FOR UPDATE SKIP LOCKED в запросе, который читает неопубликованные строки".
Deadlock: обнаружение и единый порядок блокировок
Postgres не предотвращает deadlock заранее — он его обнаруживает после факта: если транзакция ждёт блокировку дольше deadlock_timeout (по умолчанию 1 секунда), Postgres строит граф ожидания блокировок и проверяет его на цикл. Если цикл найден — одна из транзакций (обычно та, что определяется дешевле откатить) получает ошибку deadlock detected и откатывается, вторая продолжает как ни в чём не бывало.
Классический сценарий: транзакция A блокирует строку товара X, затем пытается заблокировать Y; транзакция B в это же время уже заблокировала Y и пытается заблокировать X — обе ждут друг друга бесконечно, пока не сработает обнаружение deadlock.
Единый порядок захвата блокировок — общий паттерн против этого класса проблем, а не специфика одного проекта. Если все транзакции, которым может понадобиться заблокировать несколько строк, всегда берут их в одном и том же порядке (например, по возрастанию id), цикл ожидания в принципе невозможен построить — та транзакция, что должна ждать вторую строку, физически не может оказаться в ситуации, где кто-то ждёт именно её первую строку.
Сортировка позиций заказа по item_id перед последовательным списанием (уже упомянутая в статье про Avito.Кухня) — конкретное применение этого общего правила, не изобретение под конкретный проект; тот же приём применим к любой системе, где одна логическая операция берёт блокировки на несколько строк.
Advisory locks — блокировка без строки-носителя
pg_advisory_lock(key) / pg_advisory_lock(key1, key2) — блокировка на произвольное число (или пару чисел), не привязанная ни к какой строке таблицы. Полезна там, где нет естественной строки, которую можно заблокировать через FOR UPDATE, но нужна гарантия "не больше одного одновременного выполнения" — например, пересчёт агрегированного отчёта за период, где блокировать пришлось бы сразу произвольный диапазон строк, или разовая миграция/джоба, которая не должна запуститься в двух инстансах приложения одновременно.
_, err := db.ExecContext(ctx, "SELECT pg_advisory_lock($1)", lockKey)
// ... критическая секция ...
_, err = db.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", lockKey)Транзакционная версия pg_advisory_xact_lock освобождается автоматически в конце транзакции (COMMIT/ROLLBACK), без риска забыть явный unlock при панике или раннем return — обычно предпочтительнее сессионной версии именно поэтому.
Проверено вживую
Все три сценария в разделе про изоляцию выше — не теоретические утверждения, а результат реального эксперимента на одноразовом контейнере postgres:16 (поднят и снесён в рамках подготовки этой страницы, не оставлен работать): таблица из одной строки stock=10, две параллельные транзакции по -5, три варианта конкурентного доступа.
Read-then-write без блокировки дал потерянное обновление (итог 5 вместо 0) — обе транзакции прошли без единой ошибки. Атомарный UPDATE ... WHERE и SELECT ... FOR UPDATE оба дали корректный итог 0 — разными механизмами (более короткая блокировка на один оператор против удержания блокировки строки на всю транзакцию).